Skip to content

feat: implement msw plugin#3570

Open
malcolm-kee wants to merge 18 commits intohey-api:mainfrom
malcolm-kee:feat/msw-plugin
Open

feat: implement msw plugin#3570
malcolm-kee wants to merge 18 commits intohey-api:mainfrom
malcolm-kee:feat/msw-plugin

Conversation

@malcolm-kee
Copy link
Copy Markdown
Contributor

@malcolm-kee malcolm-kee commented Mar 13, 2026

Closes #1486

Summary

Implement msw plugin that generates a msw.gen.ts file with type-safe mock handler factories from OpenAPI specs. Each operation is exported as a named handler creator (<operationId>Mock) with a wildcard base URL, plus a getAllMocks helper to generate handlers for all operations at once. A createMswHandlerFactory function is also exported for custom base URL binding.

Important

Even though many expect fake data generation is part of this plugin, that probably overlaps with faker plugin. The only mock data handled by this plugin at the moment is the example defined in the OpenAPI spec.

API Design

Configuration

export default {
  plugins: [
    {
      name: "msw",
      valueSources: ["example"], // set to [] to disable example embedding
    },
  ],
};

Usage

Individual handler exports (wildcard base URL)

import { HttpResponse } from "msw";
import { setupServer } from "msw/node";
import { getPetByIdMock, updatePetMock, getInventoryMock, getAllMocks } from "./client/msw.gen";

const server = setupServer(
  // Static response — type-checked result, status defaults to dominant success code
  getPetByIdMock({ result: { id: 1, name: "Fido", photoUrls: [] } }),

  // Explicit status code
  getPetByIdMock({ status: 200, result: { id: 1, name: "Fido", photoUrls: [] } }),

  // Custom resolver — params and request body are typed
  updatePetMock(async ({ request, params }) => {
    const body = await request.json();
    return HttpResponse.json({ id: Number(params.petId), ...body }, { status: 200 });
  }),

  // Operations with spec examples — no args needed
  getInventoryMock(),
);

Handler options

MSW handler options can be passed as a second argument:

getPetByIdMock({ result: { id: 1, name: "Fido" } }, { once: true });

Custom base URL (createMswHandlerFactory)

import { createMswHandlerFactory } from "./client/msw.gen";

// Explicit base URL
const createMock = createMswHandlerFactory({
  baseUrl: "http://localhost:3000",
});

// No args — infers base URL from spec's servers field
const createMock2 = createMswHandlerFactory();

const server = setupServer(
  createMock.getPetByIdMock({ result: { id: 1, name: "Fido", photoUrls: [] } }),
);

All handlers (getAllMocks)

import { getAllMocks } from "./client/msw.gen";

// Quick setup — all operations with defaults
setupServer(...getAllMocks());

// Strict mode — missing mocks return 501
setupServer(...getAllMocks({ onMissingMock: "error" }));

// With overrides — keys are handler names (<operationId>Mock)
setupServer(
  ...getAllMocks({
    onMissingMock: "skip",
    overrides: {
      getPetByIdMock: {
        result: { id: 1, name: "Fido", photoUrls: [] },
      },
    },
  }),
);

Design decisions

Why <operationId>Mock naming? — Appending Mock avoids naming collisions with other generated artifacts (types, SDK functions) while keeping the handler clearly associated with its operation.

Why both individual exports and createMswHandlerFactory? — Individual exports use a wildcard (*) base URL for zero-config convenience. The factory function allows binding to a specific base URL when needed (e.g. integration tests against a specific server).

Why valueSources instead of example: boolean? — Extensible for future sources (e.g. ['example', 'faker'] when faker plugin is ready).

onMissingMock — Operations that require a response argument (no default example) are either skipped ('skip') or return a 501 ('error'). Overrides always take precedence.

Handler creator signatures

Operation has Parameter type Optional?
Response type with status codes { result, status? } | ToResponseUnion<Responses> | HttpResponseResolver<PathParams, Body> No*
Response type, void { result, status? } | ToResponseUnion<Responses> | HttpResponseResolver<PathParams, Body> Yes
No response type (no status code) HttpResponseResolver<PathParams, Body> Yes

* Optional if the spec defines an example for the dominant response.

Response method selection

Content type Method
application/json HttpResponse.json()
text/* HttpResponse.text()
binary/octet-stream new HttpResponse()
void / no content new HttpResponse(null)

When multiple 2xx responses exist, the dominant one is chosen by priority: json > text > binary > void.

Known limitations

  • Response type generic is omitted from HttpResponseResolver to avoid MSW's DefaultBodyType constraint issues with union/void response types
  • Query parameters are not typed in resolvers (MSW doesn't support typed query params natively)
  • Only 2xx responses are considered for the dominant response

@bolt-new-by-stackblitz
Copy link
Copy Markdown

Review PR in StackBlitz Codeflow Run & review this pull request in StackBlitz Codeflow.

@pullfrog
Copy link
Copy Markdown

pullfrog bot commented Mar 13, 2026

Error

agent completed without reporting progress

Pullfrog  | Rerun failed job ➔View workflow run | Triggered by Pullfrogpullfrog.com𝕏

@vercel
Copy link
Copy Markdown

vercel bot commented Mar 13, 2026

@malcolm-kee is attempting to deploy a commit to the Hey API Team on Vercel.

A member of the Team first needs to authorize it.

@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Mar 13, 2026

🦋 Changeset detected

Latest commit: 7594425

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 3 packages
Name Type
@hey-api/openapi-ts Patch
@hey-api/shared Patch
@hey-api/openapi-python Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@dosubot dosubot bot added size:XXL This PR changes 1000+ lines, ignoring generated files. feature 🚀 Feature request. labels Mar 13, 2026
@malcolm-kee malcolm-kee mentioned this pull request Mar 13, 2026
4 tasks
@codecov
Copy link
Copy Markdown

codecov bot commented Mar 13, 2026

Codecov Report

❌ Patch coverage is 19.36128% with 404 lines in your changes missing coverage. Please review.
✅ Project coverage is 38.40%. Comparing base (51d56b3) to head (7f335ff).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
examples/openapi-ts-fetch/src/client/msw.gen.ts 33.13% 99 Missing and 16 partials ⚠️
...kages/openapi-ts/src/plugins/msw/shared/handler.ts 1.78% 47 Missing and 8 partials ⚠️
.../src/plugins/msw/shared/computeDominantResponse.ts 8.92% 33 Missing and 18 partials ⚠️
packages/openapi-ts/src/plugins/msw/v2/plugin.ts 2.00% 48 Missing and 1 partial ⚠️
...ackages/openapi-ts/src/ts-dsl/type/tuple-member.ts 7.14% 24 Missing and 2 partials ⚠️
packages/openapi-ts/src/plugins/msw/shared/sort.ts 0.00% 14 Missing and 3 partials ⚠️
...ages/openapi-ts/src/plugins/msw/shared/response.ts 0.00% 14 Missing and 1 partial ⚠️
packages/shared/src/utils/url.ts 0.00% 5 Missing and 9 partials ⚠️
packages/openapi-ts/src/ts-dsl/expr/object.ts 37.50% 9 Missing and 1 partial ⚠️
packages/openapi-ts/src/ts-dsl/expr/spread.ts 18.18% 9 Missing ⚠️
... and 13 more
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3570      +/-   ##
==========================================
- Coverage   38.99%   38.40%   -0.59%     
==========================================
  Files         515      540      +25     
  Lines       18901    20005    +1104     
  Branches     5591     6014     +423     
==========================================
+ Hits         7370     7683     +313     
- Misses       9326     9954     +628     
- Partials     2205     2368     +163     
Flag Coverage Δ
unittests 38.40% <19.36%> (-0.59%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@mrlubos
Copy link
Copy Markdown
Member

mrlubos commented Mar 13, 2026

@malcolm-kee Before I go into it, two questions:

  1. How much AI was used to create this pull request?
  2. How much are you willing to improve it? i.e. Is this the final version?

@malcolm-kee
Copy link
Copy Markdown
Contributor Author

@mrlubos I come up with the API design and AI was doing most of the implementations while I watch.

Not final. I'm happy to iterate on this, just want some progress on this plugin.

@malcolm-kee
Copy link
Copy Markdown
Contributor Author

The diff is big is mostly because of the tests and snapshots.

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Mar 13, 2026

Open in StackBlitz

@hey-api/codegen-core

npm i https://pkg.pr.new/@hey-api/codegen-core@3570

@hey-api/json-schema-ref-parser

npm i https://pkg.pr.new/@hey-api/json-schema-ref-parser@3570

@hey-api/nuxt

npm i https://pkg.pr.new/@hey-api/nuxt@3570

@hey-api/openapi-ts

npm i https://pkg.pr.new/@hey-api/openapi-ts@3570

@hey-api/shared

npm i https://pkg.pr.new/@hey-api/shared@3570

@hey-api/spec-types

npm i https://pkg.pr.new/@hey-api/spec-types@3570

@hey-api/types

npm i https://pkg.pr.new/@hey-api/types@3570

@hey-api/vite-plugin

npm i https://pkg.pr.new/@hey-api/vite-plugin@3570

commit: cfefe18

@malcolm-kee
Copy link
Copy Markdown
Contributor Author

I did some refactoring/enhancements:

  • remove duplications of type definition and function param definition. It's only defined at type level and the implementation param is auto-inferred from that.
  • extract example from schema as default value.

@malcolm-kee
Copy link
Copy Markdown
Contributor Author

malcolm-kee commented Mar 14, 2026

@mrlubos I manually validated the code and made some refactoring. It would be great if you can provide some feedbacks, especially on the API.

@malcolm-kee malcolm-kee force-pushed the feat/msw-plugin branch 2 times, most recently from ac3c300 to b34b5bd Compare March 15, 2026 04:36
@malcolm-kee
Copy link
Copy Markdown
Contributor Author

malcolm-kee commented Mar 15, 2026

More revision:

  • Redesign the API - the static parameter becomes { status: number; result: ResultType } instead of just ResultType. This design allows typescript to infer the types better with a stable object type with explicit properties instead of a more generic ResultType. This also make it easier to overwrite the status code without falling back to use the custom resolver approach.
  • Forward the handler options to msw, so no lost of capabilities.
  • Added resolveToNull helper function to remove duplicate fallbacks

@malcolm-kee
Copy link
Copy Markdown
Contributor Author

Added examples options to the plugin.

@malcolm-kee
Copy link
Copy Markdown
Contributor Author

Change plugin options from examples: boolean to valueSources: Array<'example'>, so that when fakerjs is ready, we just need to switch the default value to valueSources: ['example', 'faker'].

@malcolm-kee malcolm-kee force-pushed the feat/msw-plugin branch 2 times, most recently from e3e18f0 to 7bb2ae4 Compare March 16, 2026 09:20
@malcolm-kee
Copy link
Copy Markdown
Contributor Author

malcolm-kee commented Mar 16, 2026

Ideas on how to continue enhancing this PR, in case anyone want to take over this, since I might not be free to iterate on this:

Implement ofAll

We can actually implement ofAll helper without waiting for faker plugin, by providing options to customize its behavior:

const createMock = createMswHandlerFactory();

const allMockHandlers = createMock.ofAll({
  onMissingMock: 'skip', // 'skip' | 'error',
  overrides: {
    getPetById({
      status: 200,
      result: { id: 1, name: "Fido", photoUrls: [] },
    })
  }
});

const server = setupServer(...allMockHandlers);

onMissingMock option:

  • skip: we will not include the MSW handlers that requires argument in the returned array of requestHandlers. This is possible because we can infer if argument is required for a handler (I differentiate them by providing them different types - HttpHandlerFactory are those require argument while OptionalHttpHandlerFactory are those does not require argument)
  • error: we will include the MSW handlers for all, but for those require argument, we will return HttpResponse('[heyapi-msw] The mock of this request is not implemented.', { status: 501 })

overrides option API is similar to the API of single handler, but instead of calling individual helper, user can define all of them at once here.

@vercel
Copy link
Copy Markdown

vercel bot commented Mar 25, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
hey-api-docs Ready Ready Preview, Comment Mar 25, 2026 11:23pm

Request Review

@mrlubos
Copy link
Copy Markdown
Member

mrlubos commented Mar 26, 2026

@malcolm-kee this is a really strong start! I found some errors with the OpenCode spec so I'm going to clean it up either way, here are the changes I've made so far:

  1. Fallback base URL to *. It's easier to get something going that way, otherwise I was stuck on the very first step and had to figure out why it's not matching mocks.
  2. Removed the _default mocks. It's easy to import and call createMswHandlerFactory() now to get the same result. I was bitten way too many times by the default HTTP clients we generate, not going to repeat the same mistake.
  3. Added baseUrl allowing people to define their own default value or pick one from OpenAPI servers.
  4. Smaller cleanup to the DSL usage.

I'll continue working on this, the main thing right now is the readability and organization of the generated code

@malcolm-kee
Copy link
Copy Markdown
Contributor Author

malcolm-kee commented Mar 26, 2026

@mrlubos I'm thinking of adding SSE support if #3657 looks good. But probably after this PR is merged.

@mrlubos
Copy link
Copy Markdown
Member

mrlubos commented Mar 27, 2026

@malcolm-kee New changes:

  1. Removed the onMissingMock feature. Let me know if this is essential to you, I wanted to reduce the complexity for now. The default MSW warning works imo, but we might want to bring this back later since MSW Source also has something similar.
  2. Moved single handler factories behind .one property to guarantee uniqueness. If we want to support more customization later, the original mocks spread could clash with getAllMocks. Speaking of...
  3. Renamed .getAllMocks() to .all().
  4. Major clean up of the output. The new structure exports every handler factory in addition to the umbrella wrapper. This unlocks a lot of goodies such as conditionally disabling the wrapper generation (if desired) or the ability to move individual exports to different files.

The last part is improving the actual handlers and fixing some bugs I found.

@malcolm-kee
Copy link
Copy Markdown
Contributor Author

malcolm-kee commented Mar 27, 2026

Removed the onMissingMock feature.

I'm concerned that it introduces invalid mock. In my experience, invalid mocks are a nightmare to debug because the resulting errors usually pop up in the UI logic, far away from the network layer.

We should probably force overrides to be required or ensure those handlers are skipped. The former approach makes the all helpers harder to use, while the latter is misleading as it's not all handlers - neither is good DX IMO.

Moved single handler factories behind .one property

I think it could be confusing, given that MSW handler has once option. What about .mock?

Major clean up of the output

Could we add explicit return type for createMswHandlers? Else the output will break projects with isolatedDeclarations mode.


Other than that, LGTM!

@mrlubos
Copy link
Copy Markdown
Member

mrlubos commented Mar 27, 2026

@malcolm-kee added explicit types back.

RE .one, this is the current result from the factory. It's closer to an ORM interface so I'd be surprised if people confuse it with .once – what would the assumption be? I can also add JSDoc comments to this interface later, I'm just being lazy right now.

Screenshot 2026-03-27 at 11 52 20 pm

RE onMissingMock, can you explain your concern? I assume you're saying that the mock could return data that doesn't match the expected shape which would explode your UI. That's a valid concern, but with your implementation I was using all mocks and MSW was not detecting them which felt weird too. It wasn't intuitive that I need to manually provide a response. Perhaps a middle ground would be to avoid handling operations if we can't guarantee the result is accurate. That way we're not forced to manually provide every response and .all() could be improved with a comment explaining how it works.

@malcolm-kee
Copy link
Copy Markdown
Contributor Author

I want to make sure I understand your concern correctly — could you clarify a bit?

It wasn't intuitive that I need to manually provide a response

Were you using onMissingMock: 'skip' or 'error'? If the issue is that .all() silently skips mocking some requests, 'error' would surface which endpoints aren't being mocked. On the other hand, 'skip' allows relying on MSW's default unhandled request behavior to catch those.

I agree it feels odd that .all() doesn't actually mock all endpoints. The end goal is to provide sensible defaults for every endpoint (via the faker plugin when it's ready), so for now, skipping is the safer default to avoid returning invalid data.

If it's too confusing, we can ship this plugin without all() for now and add it once faker plugin is ready.

@malcolm-kee
Copy link
Copy Markdown
Contributor Author

One more thing, not sure if it's intentional. I realize that in the latest implementation, all the first parameter becomes optional. It was made required previously as a signal to user that they must provide the mock response.

@mrlubos
Copy link
Copy Markdown
Member

mrlubos commented Mar 28, 2026

@malcolm-kee good eye – that one is intentional. If we're skipping or throwing an error on missing mocks, there's no need to provide any arguments. More importantly, this is the base experience I want people to have:

const server = setupServer(
  ...createMswHandlers().all(),
);
server.listen();

or with single handler:

const server = setupServer(
  createMswHandlers().one.tuiPublish(),
);
server.listen();

As with the rest of the ecosystem, it should just work™. In the end, the behavior I'm steering towards with this v0 is mock what we can (based on the examples field), and skip everything else. The above examples feel good to me, just need to bring back the onMissingMock behavior from your last comment.

After playing with it more, I agree that fake data is very high on the priority list. A lot of specs simply don't have example fields, which would make the default experience basically do nothing haha. Did you have an interest in working on that feature?

@malcolm-kee
Copy link
Copy Markdown
Contributor Author

malcolm-kee commented Mar 29, 2026

I agree with the principle of it should just work, but I want to push back a little, as I disagree that making the parameter optional is achieving that.

There are two scenarios when someone add a msw handlers:

  • scenario 1: a flow that fetch some data from backend and use it for subsequent processing, e.g. display in UI
  • scenario 2: a flow that make some API calls to backend, then doesn't care about its response as long it is returning successful status code

By making the parameter optional in all cases, we're making the life for scenario 2 easier but at the cost of scenario 1. Now that instead of letting the type guide them on which mock handlers can be used safely, they have to wait until the runtime error in the test to let them know if a manual mock data is required.

A middle ground that we can attempt is to apply different logic based on the method - get method should force the parameter as required when fallback is not available, and other methods can be always optional. I didn't do that because I tried to avoid making the logic even more complicated.

Last thing I want to point out is that this is a decision we had to make even with the faker plugin, because user might disable default value from faker with valueSources: [].

@malcolm-kee
Copy link
Copy Markdown
Contributor Author

Yeah I'm interested in working on the faker plugin, didn't do anything yet cause I assumed you've started something?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature 🚀 Feature request. size:XXL This PR changes 1000+ lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

MSW plugin

3 participants